Skip to content

feat(admin): scoped user-management with approval queue (#64, #85)#91

Open
martinydeAI wants to merge 4 commits into
feature/issue-84-roles-and-voterfrom
feature/issue-85-admin-user-management
Open

feat(admin): scoped user-management with approval queue (#64, #85)#91
martinydeAI wants to merge 4 commits into
feature/issue-84-roles-and-voterfrom
feature/issue-85-admin-user-management

Conversation

@martinydeAI

@martinydeAI martinydeAI commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Links to issues

Closes #64.
Closes #85.

Implements PR 5 of the User management milestone plan.

! This pr should not be handled before other User management PRs have been merged to keep a proper comparison.

do-not-merge — depends on PR #87 (#84) AND PR #86 (#45 + #83).

This branch is feature/issue-84-roles-and-voter (PR #87) with
feature/issue-45-user-entity-extension (PR #86) merged in. The
branch needs PR #87's voter + EmailDomain helper AND PR #86's
User.status field. Once both PRs land:

  1. Rebase this branch onto fresh develop (drop the merge commit,
    keep the feature commit on top).
  2. Remove the do-not-merge label.
  3. Drop the block reason from the body.

Description

Admin user-management surface:

  • GET /admin/users — list view scoped by role. Admins see every
    user, domain managers see only same-domain users (via the
    EmailDomain helper). Optional ?status=pending|approved|blocked
    filter powers the approval queue (feat: approval queue for domain managers #64).
  • GET /admin/users/pending — sugar route, 302 to
    /admin/users?status=pending so feat: approval queue for domain managers #64's URL keeps working.
  • POST /admin/users/{id}/approve and .../block — flip the user's
    status. Gated by IsGranted('APPROVE_USER' / 'BLOCK_USER', subject: $user) so PR feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter (#84) #87's voter fires on every transition. CSRF-protected
    with the admin-user-action intent token.
  • App\Security\UserApproval — single home for the
    Pending -> Approved / Approved -> Blocked transitions.
  • App\Repository\UserRepository::findVisibleTo() — scoped finder
    with optional status filter. Domain managers without an email
    defensively get an empty result (defence in depth — the firewall
    • voter would already block, but the repository falls through
      rather than running a query against an unresolved domain).
  • ^/admin access_control rule + per-action IsGranted
    belt-and-braces.
  • Template re-uses Form/Button, Form/Label, Form/TextInput,
    Eyebrow, and base layout — no new component families.
  • back parameter on the action forms only honours /admin/users…
    URLs (hostile-redirect guard).

Screenshot of the result

n/a — UI matches the existing layout primitives. Manual screenshot
can be added on request.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Verified locally:

  • task coding-standards-check — all green.
  • task test-coverage — 85 tests, 252 assertions, 100 %
    coverage
    .

Additional comments or questions

The "domain manager promotion" UI is not in scope. Granting a
user ROLE_DOMAIN_MANAGER (or revoking it) is still done via the
console for now — separate UX decision, not tracked as its own issue
yet.

The voter's IsGranted runs before the controller body, so a
cross-domain POST yields 403 from the voter — never reaching the
CSRF check. Both paths are exercised by the test suite.

#12 (broader RBAC meta) can be closed when this PR lands per the
plan — its concrete payload is now the role set in #84 + the scoped
admin access here.


Details - AI specificities

martinydeAI and others added 3 commits June 19, 2026 08:54
Lands the User-entity work of ADR 006 — a `UserStatus` PHP enum
(`pending | approved | blocked`) and a matching `status` column on
the entity, plus the long-pending `name` display column. The
`UserManager::createUser()` service grows a required `name` argument
and an optional `status` (defaults to `Approved` for the console /
fixture path; the registration flow in #62 will pass `Pending`).
The `app:user:create` console command grows a third `name`
argument; fixtures seed Alice + Bob with display names.

A single migration adds both columns with a default value so
existing rows in long-running environments backfill cleanly:
`name = ''` and `status = 'approved'` (historic rows are assumed
already-trusted accounts).

Combines #45 (User.name) and #83 (UserStatus enum + status field)
in one PR, since the migration is most coherent as a single ALTER
TABLE and the entity tests fight if either field lands without the
other. Sequenced as PR 1 of the User management milestone plan
(`docs/plans/user-management-milestone.md`, written locally) —
PR 3 (UserChecker, #63) and PR 4 (Registration, #62) stack on top
of this branch with `do-not-merge` labels until it merges.

Closes #45.
Closes #83.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the admin user-management surface decided in ADR 006:

- `App\Controller\Admin\UserController` exposes `/admin/users` (list),
  `/admin/users/pending` (redirects to the pending-filtered list), and
  POST-only `/admin/users/{id}/{approve|block}` actions. Class-level
  `IsGranted('ROLE_DOMAIN_MANAGER')` plus per-action
  `IsGranted('APPROVE_USER'|'BLOCK_USER', subject: $user)` gates
  through PR 2's `ManageUserVoter` so the same-domain check fires
  on every state transition.
- `App\Security\UserApproval` owns the `Pending -> Approved` and
  `Approved -> Blocked` transitions (and their reverses). One place
  to add audit logging later if needed.
- `App\Repository\UserRepository::findVisibleTo()` scopes the list
  query: admins see every user, domain managers see only same-domain
  users via `LOWER(email) LIKE '%@<domain>'`, anyone else sees an
  empty result. Optional status filter via the `UserStatus` enum so
  `/admin/users?status=pending` powers the approval-queue subset.
- `templates/admin/user/list.html.twig` renders the table with the
  existing `Form/Button` + `Eyebrow` components and per-row action
  forms. CSRF-protected via Symfony's `csrf_token('admin-user-action')`
  helper; the `back` parameter on each action only honours `/admin/users…`
  URLs so a hostile actor can't redirect off-site.
- `security.yaml` adds an `^/admin` access-control rule as a second
  layer in front of the controller `IsGranted`.

Sequenced as PR 5 of the User management milestone plan. Branched
from PR 2 (#87, voter + Roles + EmailDomain) and merged in PR 1
(#86, User.name + User.status) since both are needed; labelled
`do-not-merge` until both bases land. Closes #12's broader RBAC
umbrella along with the concrete sibling closure plan.

Closes #64.
Closes #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@martinydeAI martinydeAI added the do-not-merge Block merging until external dependency lands label Jun 19, 2026
Removed references to ADR 006 and added clarity to column descriptions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge Block merging until external dependency lands

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants